Using the JShell API to implement a Java Source Browser

In addition to being a command line REPL tool, jshell also offers a programmatic API.

Starting with JDK 19, jshell highlights keywords, identifiers and deprecated APIs in Java snippets. That new feature is also exposed via the jshell API.

The example below shows how to implement a simple Java source browser using the JShell API and the Simple Web Server API introduced in JDK 18. As it is implemented as a Java shebang script, you can invoke directly. You only need to pass a directory containing some Java sources.

./srcbrowser ../../jdk/open/src



And open your browser on http://localhost:8080 to browse the source code.



Sources

#!/usr/bin/java -source 19

/*
 * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *   - Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 *
 *   - Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 *
 *   - Neither the name of Oracle nor the names of its
 *     contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import java.io.*;
import java.nio.file.*;
import java.net.*;
import java.util.*;
import java.util.stream.*;
import java.util.function.Predicate;
import java.util.spi.ToolProvider;
import jdk.jshell.*;
import com.sun.net.httpserver.*;
import static com.sun.net.httpserver.SimpleFileServer.*;

/**
 * Serves the default file system via webserver.
 * Decorates .java source files as HTML using JShell APIs.
 *
 * ./srcbrowser [<base dir>] [<port>]
 */
class srcbrowser {
  // base path to serve
  private static Path BASE_DIR;

  public static void main(String[] args) throws Exception {
    BASE_DIR = args.length > 0? Paths.get(args[0]) : Paths.get(".");
    BASE_DIR = BASE_DIR.toAbsolutePath();
    var fileHandler = SimpleFileServer.createFileHandler(BASE_DIR);

    // if it's a java source file, then send HTML generated using jshell
    // else use the default (file system) handler
    var handler = HttpHandlers.handleOrElse(
      srcbrowser::isJavaSource, srcbrowser::sendHtmlForJava, fileHandler);
    var output = SimpleFileServer.createOutputFilter(
      System.out, OutputLevel.VERBOSE);

    var port = args.length > 1? Integer.parseInt(args[1]) : 8080;
    var lookback = new InetSocketAddress(port);
    var server = HttpServer.create(lookback, 10, "/", handler, output);
    System.out.printf("visit http://localhost:%d/ from your browser..", port);
    server.start();
  }

  // is this a .java source file
  private static boolean isJavaSource(Request r) {
    return r.getRequestURI().toString().endsWith(".java");
  }

  private static void sendHtmlForJava(HttpExchange exchange) {
    try {
      var path = BASE_DIR.resolve(
        // get rid of initial '/' from the URI
        exchange.getRequestURI().toString().substring(1));
      if (!Files.exists(path)) {
        exchange.sendResponseHeaders(404, -1);
        exchange.close();
      } else {
        exchange.sendResponseHeaders(200, 0);
        var out = new PrintStream(exchange.getResponseBody(), true);
        var src = Files.readString(path);
        out.println(srcToHTML(src));
        exchange.close();
      }
    } catch (IOException exp) {
      exp.printStackTrace();
    }
  }

  private static String srcToHTML(String src) {
    var jshell = JShell.builder().executionEngine("local").build();
    var srcAnalysis = jshell.sourceCodeAnalysis();
    var buf = new StringBuffer();
    buf.append("<html><body><pre><code>");
    int index = 0;
    for (var hl : srcAnalysis.highlights(src)) {
      if (index < hl.start()) {
        buf.append(htmlEncode(src.substring(index, hl.start())));
      }
      buf.append(decorate(hl.attributes(), 
          src.substring(hl.start(), hl.end())));
      index = hl.end();
    }
    buf.append(htmlEncode(src.substring(index)));
    buf.append("</pre></code></body></htm>");
    return buf.toString();
  }

  private static String decorate(Set<SourceCodeAnalysis.Attribute> attrs, String content) {
    var buf = new StringBuilder();
    // start tags
    buf.append(
      attrs.stream().map(attr -> switch(attr) {
          case DECLARATION -> "<b>";
          case DEPRECATED -> "<s>";
          case KEYWORD -> "<font color=\"red\">";
      }).collect(Collectors.joining()));
    // content inside
    buf.append(htmlEncode(content));
    // end tags
    buf.append(
      attrs.stream().map(attr -> switch(attr) {
          case DECLARATION -> "</b>";
          case DEPRECATED -> "</s>";
          case KEYWORD -> "</font>";
      }).collect(Collectors.joining()));
    return buf.toString();
  }

  private static String htmlEncode(String src) {
    var buf = new StringBuilder();
    for (char c : src.toCharArray()) {
       switch (c) {
          case '<' -> buf.append("&lt;");
          case '>' -> buf.append("&gt;");
          case '&' -> buf.append("&amp;");
          case '"' -> buf.append("&quot;");
          default -> buf.append(c);
       }
     }
     return buf.toString();
  }
}